Cash-flow analysis

I’ve already shown in some other notebook that we can extract and display information on the cash flows of, e.g., a bond; but it was somewhat in passing. Let’s have a better look at that.

import QuantLib as ql
import pandas as pd
today = ql.Date(28, ql.July, 2025)
ql.Settings.instance().evaluationDate = today

A complicated family

The diagram below shows part of the hierarchy starting from the CashFlow class (which in turn inherits from Event, but let’s ignore it here). There are all kinds of different cash flows there, including some sub-hierarchies like the one with its base in the Coupon class.

classDiagram
    class CashFlow {
        date()*
        amount()*
        hasOccurred()
    }

    class SimpleCashFlow
    CashFlow <|-- SimpleCashFlow

    class Redemption
    SimpleCashFlow <|-- Redemption

    class Coupon {
        date()
        rate()*
        nominal()
        accrualPeriod()
        dayCounter()*
        accruedAmount(date)*
    }
    CashFlow <|-- Coupon

    class FixedRateCoupon {
        rate()
        amount()
        dayCounter()
        accruedAmount(date)
    }
    Coupon <|-- FixedRateCoupon

    class FloatingRateCoupon {
        rate()
        amount()
        dayCounter()
        accruedAmount(date)
        index()
        fixingDate()
    }
    Coupon <|-- FloatingRateCoupon

    class IborCoupon
    FloatingRateCoupon <|-- IborCoupon

    class OvernightIndexedCoupon {
        fixingDates()
        averagingMethod()
    }
    FloatingRateCoupon <|-- OvernightIndexedCoupon

    class InflationCoupon {
        rate()
        amount()
        dayCounter()
        accruedAmount(date)
        observationLag()
    }
    Coupon <|-- InflationCoupon

    class CPICoupon {
        baseDate()
        observationInterpolation()
    }
    InflationCoupon <|-- CPICoupon

    class IndexedCashFlow {
        notional()
        baseDate()
        fixingDate()
        index()
    }
    CashFlow <|-- IndexedCashFlow

    class ZeroInflationCashFlow {
        observationInterpolation()
    }
    IndexedCashFlow <|-- ZeroInflationCashFlow

Depending on their actual class, they might provide multiple different pieces of information. The only things that they all have in common, though, are those declared in the interface of the base CashFlow class: a payment date and an amount. Other methods don’t belong there, since they’re not applicable to all cash flows. A redemption doesn’t have a rate; a fixed-rate coupon doesn’t have an observation lag.

Lost in translation

This poses a problem. Let’s take a sample fixed-rate bond as an example.

schedule = ql.MakeSchedule(
    effectiveDate=ql.Date(8, ql.April, 2025),
    terminationDate=ql.Date(8, ql.April, 2030),
    frequency=ql.Semiannual,
    calendar=ql.TARGET(),
    convention=ql.Following,
    backwards=True,
)

settlement_days = 3
face_amount = 10_000
coupon_rates = [0.03]

bond = ql.FixedRateBond(
    settlementDays=3,
    faceAmount=10_000,
    schedule=schedule,
    coupons=[0.03],
    paymentDayCounter=ql.Thirty360(ql.Thirty360.BondBasis),
)

We can extract its cash flows and use the CashFlow interface to display their dates and amounts:

cashflows = bond.cashflows()

data = []
for cf in cashflows:
    data.append((cf.date(), cf.amount()))

pd.DataFrame(data, columns=["date", "amount"]).style.format(
    {"amount": "{:.2f}"}
)
  date amount
0 October 8th, 2025 150.00
1 April 8th, 2026 150.00
2 October 8th, 2026 150.00
3 April 8th, 2027 150.00
4 October 8th, 2027 150.00
5 April 10th, 2028 151.67
6 October 9th, 2028 149.17
7 April 9th, 2029 150.00
8 October 8th, 2029 149.17
9 April 8th, 2030 150.00
10 April 8th, 2030 10000.00

We can see from the table above that the returned cash flows contain the interest-paying coupons as well as the redemption; the latter is returned a separate cash flow, even though it has the same date as the last coupon.

The problem surfaces when we try to extract additional information from, say, the first coupon. If we ask it for its rate, it’s going to complain loudly.

try:
    print(cashflows[0].rate())
except Exception as e:
    print(f"{type(e).__name__}: {e}")
AttributeError: 'CashFlow' object has no attribute 'rate'

That’s because, even though we’re working in Python here, we’re wrapping a C++ library and we’re subject to the constraints of the latter language. To return the set of its cash flows, the Bond class needs to collect them in a vector, which is homogeneous in C++: all elements must belong to a common type, and that would be the base CashFlow class. For polymorphism to work properly, the library also needs to work with pointers (smart pointers, usually). This results in the following return type:

typedef std::vector<ext::shared_ptr<CashFlow> > Leg;

We can confirm it by asking the Python interpreter to visualize the first coupon in the list; the SWIG-generated message contains its type.

cashflows[0]
<QuantLib.QuantLib.CashFlow; proxy of <Swig Object of type 'ext::shared_ptr< CashFlow > *' at 0x114a989f0> >

How can we retrieve additional info, then? In C++, we could use a cast; something like

auto coupon = ext::dynamic_pointer_cast<Coupon>(cashflows[0]);

resulting in a pointer to a Coupon instance (possibly a null one, if the cast didn’t succeed because the type didn’t match). From that pointer, we can access the additional interface of the Coupon class. The same goes for any other specific class.

However, there is no such cast operation in Python, where objects retain the type they’re created with. What we had to do was add to the wrappers a set of small functions performing the cast, such as

ext::shared_ptr<Coupon> as_coupon(ext::shared_ptr<CashFlow> cf) {
    return ext::dynamic_pointer_cast<Coupon>(cf);
}

Once exported to the Python module, they give us the possibility to downcast the cash flows and ask for more specific information:

c = ql.as_coupon(cashflows[0])
print(f"{c.rate(): .2%}")
 3.00%

Like the underlying cast operation, the function above returns a null pointer if the cast is not possible; for instance, if we try to convert the redemption (the last cash flow in the sequence) into a coupon. In Python, that translates into a None.

print(ql.as_coupon(cashflows[-1]))
None

As I mentioned, the QuantLib module provides a number of these functions for different classes:

[
    getattr(ql, x)
    for x in dir(ql)
    if x.startswith("as_")
    and (x.endswith("coupon") or x.endswith("cash_flow"))
]
[<function QuantLib.QuantLib.as_capped_floored_yoy_inflation_coupon(cf)>,
 <function QuantLib.QuantLib.as_coupon(cf)>,
 <function QuantLib.QuantLib.as_cpi_coupon(cf)>,
 <function QuantLib.QuantLib.as_fixed_rate_coupon(cf)>,
 <function QuantLib.QuantLib.as_floating_rate_coupon(cf)>,
 <function QuantLib.QuantLib.as_inflation_coupon(cf)>,
 <function QuantLib.QuantLib.as_multiple_resets_coupon(cf)>,
 <function QuantLib.QuantLib.as_overnight_indexed_coupon(cf)>,
 <function QuantLib.QuantLib.as_sub_periods_coupon(cf)>,
 <function QuantLib.QuantLib.as_yoy_inflation_coupon(cf)>,
 <function QuantLib.QuantLib.as_zero_inflation_cash_flow(cf)>]

Cash-flow analysis, at last

Given the functions above, we can collect a lot more information when cycling over cash flows. For instance, here we detect the coupons by trying to cast them and use their specialized interface to extract nominal, rate and accrual period; when the cast fail (that is, for the redemption) we fall back to extracting date and amount. By selecting the correct casting function, we can analyze cash flows from other kinds of bonds and collect the relevant information in each case.

data = []
for cf in cashflows:
    c = ql.as_coupon(cf)
    if c is not None:
        data.append(
            (
                c.date(),
                c.nominal(),
                c.rate(),
                c.accrualPeriod(),
                c.amount(),
            )
        )
    else:
        data.append((cf.date(), None, None, None, cf.amount()))

pd.DataFrame(
    data, columns=["date", "nominal", "rate", "accrual period", "amount"]
).style.format(
    {
        "nominal": "{:.0f}",
        "rate": "{:.1%}",
        "accrual period": "{:.4f}",
        "amount": "{:.2f}",
    }
)
  date nominal rate accrual period amount
0 October 8th, 2025 10000 3.0% 0.5000 150.00
1 April 8th, 2026 10000 3.0% 0.5000 150.00
2 October 8th, 2026 10000 3.0% 0.5000 150.00
3 April 8th, 2027 10000 3.0% 0.5000 150.00
4 October 8th, 2027 10000 3.0% 0.5000 150.00
5 April 10th, 2028 10000 3.0% 0.5056 151.67
6 October 9th, 2028 10000 3.0% 0.4972 149.17
7 April 9th, 2029 10000 3.0% 0.5000 150.00
8 October 8th, 2029 10000 3.0% 0.4972 149.17
9 April 8th, 2030 10000 3.0% 0.5000 150.00
10 April 8th, 2030 nan nan% nan 10000.00

Other functions

QuantLib provides a few other functions that act on a sequence of cash flows, rather than a single one. They are grouped as static methods of the CashFlows class; for instance, they include functions to calculate the present value and basis-point sensitivity, given a discount curve.

discount_curve = ql.YieldTermStructureHandle(
    ql.FlatForward(0, ql.TARGET(), 0.04, ql.Actual360())
)
ql.CashFlows.npv(cashflows, discount_curve, False)
9625.543550654686
ql.CashFlows.bps(cashflows, discount_curve, False)
4.535150508988854

The last False parameter in the two calls above specifies that, if one of the cash flows were paid on the evaluation date, it should not be included.

Of course, these functions can also be used on a single cash flow by passing them a list with a single element; below, they are used to augment the analysis we performed earlier.

def npv(c):
    return ql.CashFlows.npv([c], discount_curve, False)


def bps(c):
    return ql.CashFlows.bps([c], discount_curve, False)


data = []
for cf in cashflows:
    c = ql.as_coupon(cf)
    if c is not None:
        data.append(
            (
                c.date(),
                c.nominal(),
                c.rate(),
                c.accrualPeriod(),
                c.amount(),
                npv(c),
                bps(c),
            )
        )
    else:
        data.append(
            (cf.date(), None, None, None, cf.amount(), npv(cf), None)
        )

pd.DataFrame(
    data,
    columns=[
        "date",
        "nominal",
        "rate",
        "accrual period",
        "amount",
        "NPV",
        "BPS",
    ],
).style.format(
    {
        "nominal": "{:.0f}",
        "rate": "{:.1%}",
        "accrual period": "{:.4f}",
        "amount": "{:.2f}",
        "NPV": "{:.2f}",
        "BPS": "{:.2f}",
    }
)
  date nominal rate accrual period amount NPV BPS
0 October 8th, 2025 10000 3.0% 0.5000 150.00 148.80 0.50
1 April 8th, 2026 10000 3.0% 0.5000 150.00 145.83 0.49
2 October 8th, 2026 10000 3.0% 0.5000 150.00 142.89 0.48
3 April 8th, 2027 10000 3.0% 0.5000 150.00 140.03 0.47
4 October 8th, 2027 10000 3.0% 0.5000 150.00 137.21 0.46
5 April 10th, 2028 10000 3.0% 0.5056 151.67 135.91 0.45
6 October 9th, 2028 10000 3.0% 0.4972 149.17 131.00 0.44
7 April 9th, 2029 10000 3.0% 0.5000 150.00 129.09 0.43
8 October 8th, 2029 10000 3.0% 0.4972 149.17 125.80 0.42
9 April 8th, 2030 10000 3.0% 0.5000 150.00 123.97 0.41
10 April 8th, 2030 nan nan% nan 10000.00 8265.00 nan

One last note: the BPS function could have been called also on the redemption, since it has an internal mechanism to detect different kinds of coupons (based on the Acyclic Visitor pattern, if you’re curious; I explain it in Implementing QuantLib) and would have returned 0.0. Here, I chose to call it only on coupons, resulting in a nan being displayed for the redemption.

Both choices would have been correct, I guess: I’m preferring the latter because I’m seeing BPS as the question “How much does the present value of the redemption changes if we increase its rate by 1 bp?” to which my answer would be “The redemption doesn’t have a rate to increase”. It would also be ok to answer “It doesn’t change” instead.